Skip to content

feat: transcription v1 — cloud STT (Groq / OpenAI / OpenAI-compatible) end-to-end - #184

Merged
utof merged 34 commits into
mainfrom
transcription-v1/v0.6.x
Jul 31, 2026
Merged

feat: transcription v1 — cloud STT (Groq / OpenAI / OpenAI-compatible) end-to-end#184
utof merged 34 commits into
mainfrom
transcription-v1/v0.6.x

Conversation

@utof

@utof utof commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Ships cloud-only video transcription (Groq + OpenAI + any OpenAI-compatible endpoint) end-to-end through both the desktop app and the CLI, with per-segment FTS5 storage.

Spec: docs/superpowers/specs/2026-05-02-transcription-v1-design.md
Plan: docs/superpowers/plans/2026-05-02-transcription-v1.md (T0–T13)

31 commits, 89 code files, +11,004 / −125.

What landed

Task Area Summary
T1 core Sync Transcriber trait mirroring MetadataExtractor; TranscriptionResult / TranscriptSegment / TranscriptionProgress types; CoreError::Transcription
T2 db V012 transcript + transcript_segment schema, FTS5 transcript_search via crates/db/src/schema/ codegen, WriteCmd::Transcript writer handler, proptests
T3 transcribe New crate: audio pipeline (ffmpeg extract → 16 kHz mono), provider presets, backend registry
T4 transcribe OpenAICompatibleTranscriber — one adapter covers all /v1/audio/transcriptions providers via base_url + auth_scheme; wiremock-tested error mapping
T5 app TranscriptionUseCase + single-worker FIFO queue (depth 32), AppContainer wiring, config/ flat→dir refactor, 5 new AppEvent variants
T6 cli perima transcribe + perima auth {login,logout,list}; blind key entry via dialoguer; SRT / text / JSON output
T7 desktop 8 Tauri commands, tauri-plugin-shell wiring, regenerated bindings.ts
T8 desktop/web Zustand transcription slice, transcriptsQueryOptions factory, 8 api wrappers, useDomainEvents arms
T9–T11 desktop/web <TranscribeButton /> (sidebar), <TranscribeSettingsModal />, <TranscriptionPill /> (StatusBar popover)
T12 ci ffmpeg sidecar bundling via scripts/fetch-ffmpeg-sidecar.sh + bundle.externalBin (Linux v1)

Architecture notes

  • One adapter, N providers. The OpenAI /v1/audio/transcriptions shape is the de facto STT wire spec; base_url + auth_scheme collapses Groq / OpenAI / faster-whisper-server / vLLM / speaches into ~250 LoC.
  • Per-segment storage, not per-file flat — required for "jump to where 'sunset' was said". FTS5 with unicode61 remove_diacritics 2 (explicitly not porter, which produces wrong stems for non-English).
  • Keys via keyring 3.x OS-native keychain. Stronghold rejected (Tauri v3 deprecation signalled, CLI parity broken); tauri-plugin-store rejected (plain JSON — audit failure for API keys).
  • Cancellation = no DB writes. Cloud is single-shot; "commit partial" semantics activate with the local slice.
  • Honors the existing invariants: sync port traits, writer actor as an OS thread (no second tokio runtime), one HLC per WriteCmd, soft-delete + restore arms on every FTS trigger, typed CoreError across IPC, no manual useMemo/useCallback under React Compiler.

Test plan

Automated (green locally via the pre-push hook — clippy -D warnings, cargo nextest run --workspace --all-targets, workspace doctests, cargo doc --no-deps, cargo deny check, bun run build, bun test):

  • crates/core — trait + type unit tests
  • crates/db — writer-handler integration tests against real SQLite + transcript proptests
  • crates/transcribe — audio-pipeline tests, wiremock-backed provider tests, exhaustive error-mapping tests
  • crates/app — use-case + queue tests
  • crates/cli — wiremock + synthetic WAV (hound) integration test
  • apps/desktop — vitest/RTL for the slice, all three components, and useDomainEvents

⚠️ Manual QA — NOT yet performed

Merging on automated coverage; the checklist below is unverified and is recorded here as the post-merge follow-up. No item has been run against a live provider.

  • App boots in dark mode (design-system-v1 unchanged).
  • Settings → Transcription → enter Groq key → Save → modal closes; placeholder updates to ••••••••; key survives restart (keyring read-back via has_provider_key).
  • Short video → sidebar Transcribe button → click → morphs to "Cancel · …%" → toast → heartbeat progress → completion toast.
  • StatusBar pill shows "1 transcribing"; popover lists job; cancel works; pill clears when queue empties.
  • Second file while first in flight → "1 transcribing · 1 queued" → runs after first completes.
  • Cancel in-flight → "Transcription cancelled" toast → SELECT count(*) FROM transcript; unchanged.
  • Revoked API key → error toast surfaces typed CoreError::Transcription(Auth).
  • perima transcribe video.mp4 --output srt → valid SRT matching stored segments.
  • perima transcribe podcast.mp3 --output text → works on audio-only input.
  • Backup with a transcript present → restore on fresh DB → transcript persists, API keys do not (by design).
  • End-to-end success: SELECT count(*) FROM transcript; and FROM transcript_segment; both > 0.

Known limitations (v1, by design)

  • No transcript viewer. v1 stores transcripts and surfaces job-state UX, but does not render transcript text in the UI. Reading is via CLI or raw SQL.
  • Transcripts are not in desktop search. SearchBar still hits search_index only; transcript_search is queryable via SQL/CLI.
  • ffmpeg sidecar is Linux-only. macOS/Windows build from source, consistent with the existing Linux-only precedent for cargo-mutants / cargo-fuzz / criterion / Kani.
  • Audio-only files are CLI-only in the desktop UI sense — the sidebar button reads MediaMetadata.mime_type, which audio files lack until Audio file support: no AudioExtractor registered; audio files scan but yield no metadata #175 lands. The CLI dispatches by path and works today.
  • Single-worker queue; segment-grain timestamps only; no diarization; no streaming partials.

Branch-rot commits

This branch sat open for ~3 months, and the ecosystem moved underneath it. Four commits are pure catch-up, unrelated to the slice itself:

  • cargo deny unblockpublish = false on perima-transcribe (matches every other intra-workspace crate) + ignores for backoff / instant, both unmaintained transitives of async-openai's default retry policy (cross-ref async-openai default ExponentialBackoff retries 5xx for ~15min — disable or shorten for transcription #178).
  • Advisory DB moved — lockfile bumps for RUSTSEC-2026-0204 (crossbeam-epoch → 0.9.20) and RUSTSEC-2026-0194/0195 (quick-xml → 0.41.0, pulled via plist → 1.10.0). No source changes, no new ignores.
  • Toolchain floatedrust-toolchain.toml pins channel = "stable", so clippy 1.95 added five blockers to code that was clean when T5/T6 landed. field_reassign_with_default and match_wildcard_for_single_variants ×3 fixed properly (the wildcard fix turns a future third TranscribeOutput variant into a compile error instead of a silently-passing test).
  • clippy::doc_markdown set to allow workspace-wide, following the existing module_name_repetitions = "allow" precedent. It demands backticks around mixed-case proper nouns in English prose — pure churn, no correctness value. Doc enforcement that catches reader-visible breakage is untouched: broken_intra_doc_links, private_intra_doc_links, and missing_docs all remain deny.

Worth noting for future long-lived branches: a floating stable channel means an idle branch accumulates lint blockers it never had at review time.

Follow-up issues from the spec's "Out of scope / next-slice queue" are filed after merge — the local whisper-rs adapter is priority/high and is the intended next slice.

🤖 Generated with Claude Code

utof added 30 commits May 2, 2026 21:35
Transcription v1 introduces a new adapter crate at crates/transcribe;
this slice's commits land under feat(transcribe): per the per-slice
scope discipline.
User policy: CLAUDE.md and docs/ are LOCAL-ONLY artifacts. Both files
have been repeatedly re-tracked by autonomous agents 'helpfully'
re-introducing the !CLAUDE.md and !docs/superpowers/**/*.md exception
lines. This commit removes the exceptions, untracks both via git rm
--cached (files stay on disk for local agent state), and adds a
prominent comment block at the top of .gitignore telling future agents
NOT to re-enable the tracking.

Files remain on disk for local session continuity. Cloud agents (if any
future session uses them) will need to receive plan/spec content via the
prompt or a non-tracked-file mechanism, not by reading the repo.
Adds the sync-trait STT port that all adapters (cloud HTTP, future
local whisper.cpp, future plugin sidecars) implement. Mirrors the
existing MetadataExtractor pattern: &self, Send + Sync, object-safe,
returns Result<_, CoreError>.

Types: BackendId, TranscriptSegment, TranscriptionResult,
TranscribeRequest, TranscriptionProgress, TranscriptionError.
CoreError::Transcription wraps the error enum with #[from] for
?-propagation from adapter sites.

tokio-util added to perima-core deps for CancellationToken; the sync
module is unconditionally available in tokio-util 0.7 (no feature gate).
TranscribeRequest uses a manual Debug impl because Arc<dyn Fn(...)>
is not Debug (opaque callback elided from debug output).

Spec: docs/superpowers/specs/2026-05-02-transcription-v1-design.md
…yn Fn>

Code-quality review for T1 surfaced 5 polish items, all in
crates/core/src/transcription.rs:

- transcription_error_auth_serializes_without_data: assert data
  key absent (negative case) so a future Auth-with-payload edit
  doesn't slip past silently.
- transcript_segment_round_trip_uuid_v7: assert all 5 fields
  round-trip, not just id + text.
- on_progress field doc: clarify Arc-shared-on-clone semantics.
- on_progress field WHY comment: document Arc<dyn Fn> over
  channel/Box trade-off (alternatives-discipline).
- TranscriptionError doc: trim clippy-lint editorialising out of
  rustdoc, move to a // WHY comment near the derive.
- Refinery V012: transcript + transcript_segment tables, transcript_search
  FTS5 virtual table (unicode61 remove_diacritics 2 tokenizer for
  multilingual diacritic-insensitive matching), and a non-FTS5 cascade
  trigger transcript_segment_after_transcript_update_cascade that
  propagates soft-delete + restore from a transcript header to its
  segments. Both arms present (V007->V008 bug class avoided). PKs are
  TEXT UUIDv7 matching V001..V011 practice. FK to file_uuid (immutable
  surrogate per V011), NOT blake3_hash (mutable).
- FTS5 maintenance triggers via Batch F codegen: 3 new BodyKind variants
  (TranscriptSegmentAfterInsert/Delete/Update), 3 new FTS_AGGREGATIONS
  entries (15 -> 18 total), 3 new minijinja macros, 3 new dispatch arms
  in body_kind_name. AFTER UPDATE macro covers soft-delete + restore +
  text-changed arms via inlined SELECT...WHERE gates. Snapshot accepted
  via cargo insta accept (fts_transcript_segment.snap). Existing snapshot
  tests + the 4 sibling fts_* tests stay green.
- WriteCmd::Transcript(TranscriptWriteCmd::Insert {...}) sub-enum follows
  the established sub-enum pattern (Volume(VolumeWriteCmd) etc.); cmd
  carries transcript header + Vec<segments> + device + optional
  CancellationToken + reply: ReplyTx<TranscriptId>. Handler signature
  pub(super) fn handle(conn: &mut Connection, cmd, bus) mirrors
  writer/metadata.rs::handle.
- Atomic transaction with BEGIN IMMEDIATE inside the inner helper; one
  HLC value per command (Hlc::now().pack()) stamped on every row;
  cancel-aware between BEGIN and each per-segment INSERT (closes the
  cancel-after-adapter-success race). Empty AppEvent payload uses
  IndexInvalidated::SearchIndexRebuilt today; T5 will introduce the
  dedicated TranscriptionCompleted variant.
- SqliteTranscriptRepository (writer-sender + read-pool) cheap-clone
  shape mirrors sibling adapters. Read methods deferred to T7 when the
  Tauri commands need them.
- tokio-util bumped from dev-dep to prod-dep on perima-db (cmd.rs needs
  CancellationToken in the variant).
- Integration tests verify atomicity (transcript header + 3 segments +
  FTS5 query reachability) and pre-fired-cancel rollback (no rows leak).
- 64-case proptest mirrors search_proptests shape: 7-op universe
  (insert/soft-delete/restore segment, soft-delete/restore transcript
  with cascade, edit-text, no-op) over 2 transcripts; per-op assertion
  that transcript_search match-reachable rows equal the live-state
  ground truth. ~6s wall-clock per run.
- fts_install_idempotent extended to glob transcript_search_after_*
  triggers in addition to sc_*/search_after_* (count assertion is
  18 post-bump).
…t cap, doc trims)

Code-quality review surfaced 2 important + 4 minor items, all in
crates/db files touched by T2:

- Cargo.toml: drop duplicate tokio-util.workspace dev-dep (prod dep
  is automatically visible to integration tests + benches); update the
  WHY block to mention only tokio.
- transcript_proptests.rs: drop cases cap from 64 to 32 to match the
  soft-delete-churn sibling (per-case work is comparable shape, not
  the lighter tag-churn shape with the 64-cap).
- writer/transcript.rs: compress 9-step transaction module-doc into
  one shape sentence; trim Drop-rollback comment that overstated
  'sibling-handler convention'.
- transcript_repo.rs: WHY comment on TranscriptSegmentRow.id reusing
  TranscriptId (not a separate TranscriptSegmentId).
- writer_transcript.rs: docstring on the atomicity test so readers
  know what's covered (happy-path commit + FTS trigger fire) vs what
  the cancel-rollback test + proptest cover (atomicity-under-fault).
- New crates/transcribe with lib.rs + audio.rs + providers.rs +
  registry.rs + audio_pipeline integration tests.
- AudioPipeline trait + FfmpegAudioPipeline<I: FfmpegInvoker> generic
  over the invoker (DesktopFfmpegInvoker via app.path().resolve;
  CliFfmpegInvoker via which::which). FfmpegChild Drop kills the child.
- KNOWN_PROVIDERS preset table: groq, openai, custom. AuthScheme enum:
  Bearer, XApiKey { header }, None.
- TranscriberRegistry skeleton with register / set_active / active / get.
- Workspace deps added: async-openai 0.36 (audio feature; plan spec'd
  0.32 — bumped to latest stable), which 8, keyring 3.6 (plan spec'd
  3.6 — kept; 4.0 dropped compile-time features so held back), dialoguer
  0.12, wiremock 0.6 (plan spec'd 0.7 — 0.7 does not exist on crates.io),
  hound 3.5, toml 1 (plan spec'd 0.10 — bumped to latest stable),
  tauri-plugin-shell 2, ffmpeg-sidecar 2.
- 3/3 mock-ffmpeg tests pass: canonical-flags, non-zero-exit, cancel-aborts.
…er preset notes, const fn)

Spec + code-quality reviews surfaced 1 actionable + 2 important + 4 minor:

- tests/audio_pipeline.rs: gate on #![cfg(unix)] — mocks spawn POSIX
  coreutils (true/false/sleep). Windows port deferred to ASR v2.
- tests/audio_pipeline.rs: MockFfmpeg::new const fn (clippy --all-targets).
- src/audio.rs: WHY block on Arc<I> (shared invoker across pipeline
  instances; per-job + future health-check probe).
- src/providers.rs: WHY block on KNOWN_PROVIDERS["custom"] empty fields
  (template — T5 config loader overlays user values).
- src/providers.rs: 'how to add a new provider' note on KNOWN_PROVIDERS
  doc — insert before custom; find_preset is O(N), fine for N=3-10.
Single Rust adapter covers all providers serving the OpenAI
/v1/audio/transcriptions wire format (Groq, OpenAI, faster-whisper-server,
vLLM, Whisperfile, OpenWebUI, vox-box, Azure-via-LiteLLM). Wraps
async-openai 0.36 with configurable base_url + auth_scheme + model +
file_size_limit_bytes from the ProviderPreset table.

Sync Transcriber::transcribe bridges to the async API via
tokio::task::block_in_place + Handle::block_on. The constructor verifies
the supplied tokio runtime handle is MultiThread and panics loudly
otherwise (block_in_place would deadlock on current_thread).

Pre-remux via injected Arc<dyn AudioPipeline> when the input file
exceeds the backend's size limit OR has a video MIME. The NamedTempFile
returned by the pipeline is held in a binding for the duration of the
upload so its Drop fires on both success and failure paths (avoids the
plan draft's mem::forget + manual remove_file leak-on-panic hazard).

map_async_openai_error covers every concrete OpenAIError variant in 0.36
(no wildcard arm) and maps the OpenAI-style ApiError.code values to the
five spec'd TranscriptionError discriminants: Auth, RateLimited,
QuotaExceeded, ModelNotFound, BackendUnavailable. Two helper unit tests
in the production module pin the size-heuristic + the unknown-code arm.

Tests:
- crates/transcribe/tests/error_mapping.rs: 7 unit tests over
  map_async_openai_error covering each discriminant + sibling codes.
- crates/transcribe/tests/openai_compat.rs: 3 wiremock-backed
  integration tests (happy path with synthesized 100ms WAV via hound,
  401 -> Auth end-to-end, 200 + non-JSON body -> Internal). End-to-end
  5xx is intentionally NOT tested because async-openai's default
  ExponentialBackoff retries server errors for ~15 min wall-clock; the
  unit test in error_mapping.rs covers that mapping path against the
  typed ApiError shape.

API drift adaptations from plan (async-openai 0.32 -> 0.36):
- audio().transcribe_verbose_json(req) became
  audio().transcription().create_verbose_json(req).
- AudioInput auto-converts From<P: AsRef<Path>>; builder .file(path)
  works via setter(into).
- TranscriptionSegment fields are f32 (not f64); cast lints suppressed
  with WHY-block.
- OpenAIError::StreamError is Box<StreamError>, not String.
- AuthScheme::XApiKey custom-header support is deferred (no first-class
  knob on OpenAIConfig in 0.36); falls back to Bearer for v1.
…eserve Cancelled signal)

Spec reviewer surfaced 1 substantive + 1 noteworthy:

- AuthScheme::XApiKey was deferred-to-Bearer with a WHY-block claiming
  async-openai 0.36 had no custom-header API. That claim was wrong:
  OpenAIConfig::with_header(K, V) -> Result<Self, OpenAIError> exists
  in 0.36.1 (config.rs:168). Now properly emits the custom header for
  Azure / LiteLLM / similar providers; with_header errors map to
  TranscriptionError::Internal with provider context.
- AudioError::Cancelled was silently folded into TranscriptionError::
  AudioDecode by the remux call site's blanket .map_err(|e| AudioDecode(e)),
  losing the cancel signal end-to-end. Now pre-matches Cancelled before
  the catch-all so the UI sees a real Cancelled when the user cancels
  during remux.
… marker, doc trim, dedupe tests)

Code-quality reviewer surfaced 1 important + 5 minor:

- do_transcribe -> transcribe_inner: more discoverable. T5 wiring will
  grep for transcribe to find the use-case integration point.
- Uuid::nil() segment placeholder gains TODO(T5): marker so the
  use-case implementer finds it on grep.
- map_async_openai_error doc Snapshot-tested -> Covered by ...
  (variant + field assertions, not insta snapshots) - the codebase
  uses insta elsewhere; the overloaded word was misleading.
- Delete the in-module #[cfg(test)] mod tests block. The unique
  invalid_argument_with_size_maps_to_file_too_large test moved to
  tests/error_mapping.rs alongside the other 7 mapping cases. The
  duplicated unknown_code test was already present at line 83.

Net: 9 mapping tests in tests/error_mapping.rs (was 7 + 2 in-module
duplicates). Single source of truth, no in-module/integration drift.
- 5 new AppEvent variants (Started, Progress, Completed, Cancelled, Failed).
  Failed carries the full TranscriptionError to preserve discriminant
  payloads (e.g. RateLimited.retry_after_secs).
- TranscriptionUseCase owns a flume::bounded(32) queue + single tokio
  worker. execute() returns Started { request_uuid, queue_position } or
  Cancelled. Queue full -> TranscriptionError::QueueFull (typed).
- WriteCmd::Transcript::Insert gains a request_uuid field; writer handler
  emits AppEvent::TranscriptionCompleted with the use-case's request UUID.
- crates/app/src/config.rs flat file -> config/ directory module with
  resolve_data_dir + new transcription.rs (TOML parser + saver). toml dep
  added to crates/app (NOT crates/core; core stays framework-free).
- AppContainer wires the registry from TranscriptionConfig::load + keyring
  entries, instantiates one OpenAICompatibleTranscriber per provider.
  BackendId pinned to format!("{name}:{model}").
Reviewer flagged that AppEvent::TranscriptionStarted.queue_size was
hardcoded to 1 in process_one despite the field docstring promising
"current queue size including this job". Worker now snapshots
rx.len() at dequeue and passes it as a parameter to process_one,
so the emitted queue_size matches the live observation (jobs still
queued behind this one + this job).
Adds two top-level CLI subcommands, completing the user-visible surface
for the v0.6.x transcription slice (T6):

- `perima transcribe <PATH>` — runs a transcription via the active
  provider, prints transcript text to stdout (or `--output FILE`).
  Streams per-segment progress to stderr. Exit codes: 0 success,
  2 auth/quota, 3 queue full, 130 cancelled, 1 otherwise.
- `perima auth {set,delete,has,list} [PROVIDER]` — manages keyring
  entries under service `perima.transcription`. `set` accepts hidden
  TTY input (dialoguer) or piped stdin; `delete` is idempotent;
  `list` reads `[transcription.providers.*]` from `config.toml` and
  marks providers with stored keys.

Wiring + plumbing changes the subcommands required:

- AppContainer::new_with_bus(deps, handlers, bus) — overload that
  accepts a pre-built `Bus`. The CLI shell now constructs the Bus
  before SqliteWriter::start so writer-side `AppEvent::Transcription
  Completed` (post-COMMIT) reaches handlers registered on the same
  bus the use-cases publish to. The single-construction-site
  invariant survives — the bus is built in exactly one place per
  process, just moves into the shell.
- Transcription wiring (build_transcription_use_case): overlays the
  `entry.base_url` and `entry.<provider-name>` onto the static
  ProviderPreset via Box::leak, fixing `preset = "custom"` (which
  ships an empty base_url) and resolving the BackendId-mismatch
  bug when the user's provider name diverges from the preset name.
- PERIMA_TEST_API_KEY_<provider> env-var seam — short-circuits the
  keyring lookup in tests where `keyring::mock` cannot cross a
  subprocess boundary. Production users never set it.
- PERIMA_KEYRING_MOCK env switch in main() — flips the keyring
  builder to `keyring::mock::default_credential_builder()` so the
  auth subcommand tests don't pollute the developer's real OS
  keyring during `cargo nextest run`.

Tests:
- crates/cli/tests/transcribe_test.rs — wiremock + hound-synthesised
  WAV drives `transcribe` end-to-end (happy path: text on stdout;
  401 → exit 2; clap rejects bad BCP-47), plus auth subcommand
  smoke tests (`has` exits 1 missing, `delete` idempotent, `set`
  reads piped stdin, `list` empty-config marker).
- Unit tests in cmd::transcribe::tests for BCP-47 validator + exit
  code mapping.

New runtime deps: dialoguer, keyring, regex, async-broadcast, flume,
rusqlite (read-only) on perima CLI; hound + wiremock on dev-deps.
New workspace dep: regex 1.

WHY no T7 read-API call: the SqliteTranscriptRepository read-side is
empty until T7 lands. The CLI opens a short-lived rusqlite read-only
connection to `SELECT text FROM transcript_segment` after the writer
emits Completed. Marked as a stop-gap in the WHY comment.
… dedup

- Module docstring claimed `\r`-overwriting status updates but the
  handler uses writeln! (newline-terminated). Trimmed claim to match
  shipped behaviour + noted v1 cloud adapters emit Started/Finished
  only, so per-segment overwrite would be wasted complexity.
- WHY block on u32::try_from(i64).unwrap_or(u32::MAX) said "map to a
  typed Internal error" but code saturates silently. Rewrote to
  describe the actual saturation behaviour + UX rationale (49-day
  ms-fits-in-u32 envelope).
- Promoted perima_app::container::KEYRING_SERVICE to pub + re-export
  from perima_app crate root. CLI auth subcommand now imports it
  instead of duplicating the literal. Drift impossible at compile time.
- Replaced trivial keyring_entry_constructs_with_canonical_service
  unit test with keyring_service_name_matches_app_const (asserts the
  shared invariant rather than re-exercising the keyring crate).
- Renamed auth_set_then_has_then_delete_is_idempotent test to
  auth_set_succeeds_and_delete_is_idempotent — the `has` step it
  claimed was deliberately skipped (mock keyring is process-local).
- Added one-line WHY above the `let _ = self.tx.send(...)` cluster in
  TerminalEventHandler::handle.
…iring

Adds eight new `#[tauri::command]` handlers to the desktop shell — wiring
the transcription use-case + per-provider keyring management through the
typed CoreError IPC contract:

  transcribe, cancel_transcription,
  set_provider_key, delete_provider_key, has_provider_key, list_providers,
  update_transcription_config, get_transcription_config

Adopts `tauri-plugin-shell::init()` (BEFORE `.setup`) to expose the
`app.path().resolve(.., BaseDirectory::Resource)` API used by a layered
ffmpeg sidecar discovery: bundled-sidecar → system PATH → deferred-error
`MissingFfmpegPipeline`. T12 will land the bundled binaries; until then
desktop transcription falls back to system ffmpeg gracefully.

Switches `build_container` from `AppContainer::new` → `new_with_bus` so
the writer-emitted `AppEvent::TranscriptionCompleted` (post-COMMIT) reaches
the same `TauriEventHandler` that use-case-emitted events flow through —
no event reaches the frontend without it.

Wire-types added to `crates/desktop/src/payloads.rs` (specta-derived):
`TranscribeStartedPayload`, `ListProvidersPayload`, `ProviderListEntry`.
`TranscriptionConfig` + `ProviderEntry` get specta::Type behind the
`specta` feature in `crates/app/src/config/transcription.rs` for direct
IPC use without a shell-side mirror.

`apps/desktop/src/bindings.ts` regenerated (hand-extended; the export
runs inside `lib.rs::run()` which `cargo build` doesn't trigger). The
bindings_compile integration test now covers the new commands and asserts
all five new types appear in the generated TS.

`update_transcription_config` v1 = save + warn-log "restart required"
(hot reload deferred — would require swapping the Arc<TranscriptionUseCase>
field on the AppContainer mid-flight).
- BLOCKING: ffmpeg sidecar resolution used std::env::consts::ARCH
  ("x86_64") where Tauri's externalBin rewrites to a full target
  triple ("x86_64-unknown-linux-gnu"). The bundled-sidecar branch
  would never have matched in a release build; the system-PATH
  fallback masked the bug locally but defeated the entire T12
  bundling story. Forward cargo's TARGET env var via build.rs +
  read with env!("TARGET") so the resource path matches what
  Tauri actually emits.
- IMPORTANT: list_providers swallowed provider_keyring_entry
  construction errors as has_key=false. Added tracing::warn! on
  the error branch so a system-keyring outage is diagnosable
  without piecing together other commands' error paths.
- NIT: documented the sync-I/O-on-Tauri-worker-thread tradeoff
  in update_transcription_config (sub-ms TOML write makes
  spawn_blocking dispatch overhead unjustified).
…domain events

Wires the React desktop frontend to the 5 AppEvent::Transcription* variants and
the 8 transcription Tauri commands shipped in T7.

WHY: components landing in T9–T11 (TranscribeButton, TranscribeSettingsModal,
TranscriptionPill) need a single source of truth for in-flight job state and
typed wrappers around the IPC surface. Centralising the slice + invalidations
here keeps the per-component code thin.

Changes:
- bindings.ts: hand-extend AppEvent with 5 Transcription* arms (channel-only,
  bypass tauri-specta) and CoreError::Transcription wrapping the new
  TranscriptionError discriminated union (11 variants, mirrors
  crates/core/src/transcription.rs::TranscriptionError).
- stores/ui.ts: add TranscriptionSlice with jobs: Record<request_uuid, Job>,
  startJob / updateJob / removeJob actions; updateJob + removeJob no-op on
  missing keys to defuse the auto-remove-vs-user-dismiss race.
- queries/transcripts.ts: NEW. transcriptsKeys + queryOptions factory +
  useTranscriptsByFileUuid hook with select: callback returning the latest by
  completed_at DESC. queryFn rejects with CoreError::Unsupported until the
  list_transcripts_by_file_uuid Tauri command lands (T9 follow-up); cache key
  + invalidation surface are wired today.
- api.ts: add 8 wrappers (transcribe / cancelTranscription / setProviderKey /
  deleteProviderKey / hasProviderKey / listProviders /
  updateTranscriptionConfig / getTranscriptionConfig); extend KNOWN_KINDS
  with "Transcription".
- lib/coreError.ts: extend coreErrorMessage with the inner-TranscriptionError
  switch; exhaustive-never default catches future variant adds.
- components/StatusBar.tsx::errorKindLabel: add "Transcription" arm so the
  exhaustive-never check still compiles after the bindings extension.
- hooks/useDomainEvents.ts: 5 new switch arms. Started → startJob (running)
  + invalidate per-file transcripts. Progress → updateJob (running). Completed
  → updateJob (completed) + invalidate per-file + search; auto-remove after
  5s grace via tracked-timers Set cleared on unmount. Cancelled → updateJob
  + 3s auto-remove. Failed → updateJob + notifyError (typed); no auto-remove
  (user dismisses).

Tests:
- __tests__/transcription-slice.test.ts: NEW. 9 tests covering startJob /
  updateJob / removeJob; multiple-job coexistence; missing-key no-ops;
  discriminated-union round-trip.
- __tests__/hooks/useDomainEvents.test.tsx: extended with 6 new tests for
  the 5 transcription arms + an unmount-clears-timers regression guard.
- __tests__/test-utils.tsx::resetUiStore: zero the transcription jobs map
  while keeping the slice's action closures intact across tests.

Verification:
- bun test → 19 files / 135 passed (was 12 files / 112 passed pre-T8).
- bun run build → tsc -b clean, vite build clean.
- bun run lint → eslint clean (--max-warnings 0).
- cargo doc --workspace --no-deps → clean.
- cargo nextest run -p perima-desktop --test bindings_compile → passed
  (specta-emitted bindings still consistent with hand-extended channel types).
Adds per-file transcription action with 3 visual states (idle /
running+queued / terminal). Slots the button + Settings gear stub into
FileSidebar adjacent to the hash section. 7 component tests cover all
status branches, the cancel path, and the notifyError error path.
…eWithMetadataPayload

The desktop transcribe button passed `file.relative_path` to the backend,
which forwarded it untouched to ffmpeg. ffmpeg resolves relative paths
against the desktop process cwd, so end-to-end transcription from the UI
silently ENOENT'd in any normal install where the volume root != cwd.

Fix: extend the read-side query that powers `list_files_with_metadata`
+ `list_files_with_tags` to also surface `mount_path` from a
deduplicated subquery on `volume_mounts`, plumb it through the
`FileWithMetadataRow` tuple alias + `FileWithTags` struct, and have the
desktop `FileWithMetadataPayload::From` impl materialise an
`absolute_path` string by joining `mount_path` with `relative_path` via
`PathBuf::push` (Windows-correct separators). The frontend
TranscribeButton now takes `source: string | null` and disables itself
with a "Volume not mounted" tooltip when `absolute_path === null`.

Decision: extended the `FileWithMetadataRow` type alias to a 4-tuple
`(loc, meta, quick_hash, mount_path)` rather than adding `mount_path`
to `FileLocationRecord`. WHY: mount paths are device-local and have no
home in the core domain (volumes are abstract identifiers there); the
tuple-element approach contains the change to the read path without
forcing every consumer of `FileLocationRecord` to reason about a
device-local field that's almost always None for them.
New modal: provider name + preset (Groq/OpenAI/Custom) + model +
base_url/auth_scheme (custom only) + write-only API key + set-as-active
checkbox. Save flow: setProviderKey (if key non-empty) then
updateTranscriptionConfig, mid-save locks Cancel/ESC/backdrop.
On success: invalidate providers query + onClose.

FileSidebar gear icon onClick wired from TODO stub to
useState(settingsOpen) → <TranscribeSettingsModal />.

8 component tests (TDD): open/closed render, happy-path save, empty-key
skip, mid-save disable, Auth error banner, ESC-during-save no-op,
custom preset field visibility.
CLAUDE.md bans emojis in code/UI unless explicitly requested.
Replace the providers-list "key set" indicator (🔑) with " (key set)"
text. No tests assert on the glyph, so this is a one-line user-facing
text change.
Two IMPORTANT data-integrity issues from code-quality review:

1. Save could wipe existing providers when config query had not yet
   resolved. The mutationFn used `configQuery.data ?? { providers: {} }`,
   so submitting before the query settled would spread an empty map and
   drop every other configured provider. Now the mutationFn throws a
   typed CoreError::Internal banner if data === undefined, AND the Save
   button is gated on `configQuery.isSuccess`. Belt + braces.

2. Empty (or whitespace-only) provider name would have written
   `providers[""] = entry`, producing a corrupt config row that the
   loader can't address. Save button now disabled when
   `form.name.trim() === ""` with a tooltip explaining why.

Test additions:
- Centralised `waitForSaveEnabled()` helper so the existing 5 Save-clicking
  tests wait for the configQuery + name gates to clear before clicking.
- New test #9 asserts: empty name → Save disabled; whitespace-only name
  → Save disabled (trim guard); real name → Save enabled.
…eslint disable

Two fix-up items rolled into one commit (e9a2204 → cumulative T10 polish):

1. Missing `CoreError` type import in bindings (TS2304 build break).
2. `@typescript-eslint/only-throw-error` rule still flags `throw err`
   even when `err: CoreError` is assigned first (rule does static
   analysis on the throw expression, not the assigned-type chain).
   Restored the eslint-disable-next-line directive matching the two
   pre-existing throws at lines 154 + 191.

bun run build + bun run lint now both clean.
Adds a read-only StatusBar pill that surfaces in-flight transcription jobs
from the Zustand slice. Pill hidden when empty; click opens an absolute-
positioned popover listing each job with status badge, Cancel button for
queued/running, and a title tooltip for failed jobs. Closes on ESC or
outside click. 10 vitest tests covering all status variants, sort order,
ESC close, and empty-slice auto-hide.
Wire up Tauri's externalBin sidecar slot for ffmpeg so a `bun run tauri
build` produces a Linux desktop bundle that includes the binary at the
path Tauri's externalBin rewriter emits
(`crates/desktop/binaries/ffmpeg-x86_64-unknown-linux-gnu`).

WHY a fetch script + just recipe + CI step (not committed binary):
  the static ffmpeg build is ~80 MB and platform-specific. Committing
  it bloats the repo and forces every clone to download macOS+Windows
  variants nobody on Linux dev needs. The script (Linux: johnvansickle
  static build via curl; macOS+Windows: zero-byte stub) is idempotent
  + Swatinem-cache-friendly, runs once per CI runner before any cargo
  invocation that touches perima-desktop.

WHY before `just ci` (not after): tauri-build validates externalBin
  paths during EVERY cargo compile of perima-desktop (issue
  tauri-apps/tauri#14602 — not just `tauri build`). Without the file
  on disk, `cargo {build,clippy,nextest} -p perima-desktop` aborts
  with "resource path doesn't exist". Same fix needed in mutants.yml's
  baseline-build pass (exclude_globs filters mutation generation, not
  baseline compile).

WHY a placeholder stub on macOS+Windows (not skip externalBin): the
  externalBin declaration is single-source-of-truth; per-platform
  conditionals would split tauri.conf.json. The stubs let
  cross-platform CI green; runtime invocation surfaces a clear
  BinaryNotFound from the audio pipeline. macOS + Windows real
  fetchers tracked as T12 follow-ups (filed at plan-merge).

WHY shell:allow-execute scoped to `name: "binaries/ffmpeg"` (not "*"):
  least-privilege per Tauri 2 capability spec. The runtime cannot
  spawn arbitrary shell commands — only the bundled ffmpeg sidecar.

Verification:
  - cargo build -p perima-desktop -j 2: PASS (1m 30s with sidecar
    fetched; aborts with "resource path doesn't exist" without).
  - just bindings (cargo build --features specta-export + git diff
    apps/desktop/src/bindings.ts): PASS (no drift).
  - cargo clippy -p perima-desktop -j 2 -- -D warnings: PASS.
  - cargo doc --workspace --no-deps: PASS.
  - JSON syntax (jq): tauri.conf.json + capabilities/default.json OK.
  - YAML parse (python yaml): ci.yml + mutants.yml OK.
  - resolve_audio_pipeline (T7) unchanged: bundled-first path matches
    the new `binaries/ffmpeg-{target-triple}` naming convention.

Files:
  - .github/workflows/ci.yml: `just sidecar` step before `just ci`
    + bindings-drift job.
  - .github/workflows/mutants.yml: same fetch step (baseline compile
    needs the binary even though desktop is excluded from mutation).
  - .gitignore: ignore `crates/desktop/binaries/*` except `.gitkeep`.
  - crates/desktop/binaries/.gitkeep: placeholder so the dir exists.
  - crates/desktop/capabilities/default.json: shell:allow-execute
    permission scoped to the sidecar.
  - crates/desktop/gen/schemas/capabilities.json: regenerated by
    tauri-build to reflect the new permission.
  - crates/desktop/tauri.conf.json: bundle.externalBin entry.
  - justfile: `just sidecar` recipe.
  - scripts/fetch-ffmpeg-sidecar.sh: per-OS fetch logic.

Follow-ups (filed at plan-merge):
  - macOS ffmpeg sidecar fetch (evermeet.cx static build).
  - Windows ffmpeg sidecar fetch (BtbN GitHub releases).
…ignore 2 unmaintained-crate advisories

Pre-push hook ran cargo-deny + caught:

1. error[wildcard] on perima-transcribe — every other intra-workspace
   crate (perima-app, perima-db, perima-desktop) declares
   `publish = false` to opt out of the wildcard-path-dep check
   (wildcard paths only fail for crates that COULD be published to
   crates.io). Added the same line to perima-transcribe.

2. error[unmaintained] for `backoff` (RUSTSEC-2025-0012) and `instant`
   (RUSTSEC-2024-0384). Both transitive via async-openai 0.36's default
   ExponentialBackoff retry policy. Upstream replacements exist (`backon`
   / `web-time`) but async-openai hasn't migrated. Added to deny.toml's
   ignore list with cross-reference to GH #178 (which already tracks
   disabling async-openai's retry logic for transcription).

Local cargo deny check now: advisories ok, bans ok, licenses ok, sources ok.
The advisory DB moved on since this branch's last commit; pre-push
cargo-deny flagged three new vulnerabilities. All three resolve with
semver-compatible lockfile bumps — no source changes, no new ignores.

- RUSTSEC-2026-0204 (crossbeam-epoch 0.9.18 -> 0.9.20): invalid pointer
  dereference in the `fmt::Pointer` impl for `Atomic`/`Shared` when the
  underlying pointer is null. Reached via rayon-core (blake3, image,
  criterion).

- RUSTSEC-2026-0194 + RUSTSEC-2026-0195 (quick-xml 0.38.4 -> 0.41.0):
  quadratic duplicate-attribute check and unbounded namespace-declaration
  allocation, both memory-exhaustion DoS vectors. quick-xml was pinned by
  plist 1.8.0, so plist 1.8.0 -> 1.10.0 comes along to unpin it.

  WHY low-impact here regardless: quick-xml is target-gated to macOS
  (Tauri's build-time Info.plist handling) and never compiles on Linux —
  `cargo tree -i quick-xml --workspace` returns nothing. It also only
  ever parses our own Info.plist, never untrusted input. Bumped anyway
  because the fix is free.

`cargo deny check` now: advisories ok, bans ok, licenses ok, sources ok.
rust-toolchain.toml pins `channel = "stable"`, so the toolchain floated
forward while this branch was open and clippy tightened. Pre-push caught
five failures, all in code that was clean when T5/T6 landed:

- `field_reassign_with_default` (crates/app/src/config/transcription.rs):
  build `TranscriptionConfig` with struct-update syntax instead of
  `default()`-then-assign.

- `match_wildcard_for_single_variants` x3 (crates/app/tests/
  transcription_test.rs): `TranscribeOutput` has exactly two variants, so
  the `other =>` catch-all covered precisely one. Bound the concrete
  variant with `other @ TranscribeOutput::Cancelled { .. }` — keeps the
  `{other:?}` panic message and makes a future third variant a compile
  error rather than a silently-swallowed test pass.

- `doc_markdown` x2: backticked here; the lint itself is disabled in a
  follow-up commit.

Verified: `cargo clippy --workspace --all-targets -- -D warnings` clean;
`cargo nextest run -p perima-app -p perima` 137/137 pass.
utof added 2 commits August 1, 2026 01:07
`doc_markdown` (from the pedantic group) demands backticks around any
mixed-case token it mistakes for a code identifier — "OpenAI",
"JavaScript", "GitHub", vendor and product names. It fires on ordinary
English prose in doc comments, every fire is a pure-churn edit with zero
correctness value, and clippy's default `doc-valid-idents` allowlist
does not cover the proper nouns this codebase actually uses (the
transcribe crate alone references OpenAI constantly).

Follows the existing precedent of `module_name_repetitions = "allow"` —
opt out of one pedantic lint rather than dropping the whole group.

Doc quality stays enforced by the checks that catch reader-visible
breakage: `rustdoc::broken_intra_doc_links = "deny"`,
`private_intra_doc_links = "deny"`, and `missing_docs = "deny"`.

Verified: `cargo clippy --workspace --all-targets -- -D warnings` clean.
CI failed on ubuntu-latest AND macos-latest at the "Fetch ffmpeg
sidecar" step. `just sidecar` invokes `./scripts/fetch-ffmpeg-sidecar.sh`
directly, and the file was committed as mode 100644 — permission denied
on both runners before a single byte was downloaded.

WHY this survived local verification: this working tree lives on a mount
that reports every file as `-rwxrwxrwx`, and the repo sets
`core.fileMode = false`, so git ignores on-disk mode bits entirely. The
script runs fine locally and the pre-push hook (which never executes the
CI workflow) has nothing to catch. The mode is only observable via
`git ls-files -s` — `scripts/no-cargo-test.sh` was already 100755, which
is why the lefthook `./scripts/no-cargo-test.sh` invocation always worked
and this one never did.

Set via `git update-index --chmod=+x` (an on-disk chmod is a no-op under
`core.fileMode = false`).

Also fixes `scripts/test-precommit-hook.sh`, same class — not currently
invoked by CI or lefthook, but it is a `./`-executable dev helper and
would fail identically the first time someone ran it.

Verified: `git ls-files -s scripts/` now reports 100755 for all three.
The bindings-drift job failed while all three matrix jobs passed, at the
same "Fetch ffmpeg sidecar" step:

    Downloading ffmpeg static build from https://johnvansickle.com/...
    Extracting ...
    xz: (stdin): File format not recognized
    tar: Child returned status 1

Timestamps show the 41 MB "download" completing in 0.3 seconds, so the
body was never the tarball.

WHY curl could not catch it: johnvansickle.com throttles bursts from a
single GitHub Actions egress range by serving a short HTML notice with
HTTP status 200 — not a 4xx/5xx. Both `--fail` and `--retry` key off the
status code, so curl reports success and hands tar a truncated file. The
resulting error names xz, which reads like archive corruption and points
the reader at entirely the wrong layer. CI fetches this tarball 4× per
run (3-platform matrix + bindings-drift), so later jobs get throttled
while earlier ones succeed — exactly the observed pattern.

Fix: validate the payload ourselves and treat a too-small body as a
retryable condition, in a loop around download-plus-validation with
linear backoff (10/20/30/40s, 5 attempts). A `curl --retry` alone cannot
help here because curl never sees an error.

On give-up the message says rate-limiting and dumps the first 200 bytes
of the response, so the next person reads the real cause instead of an
xz complaint.

Cross-ref #183, which tracks the durable fix (sha256 pin + mirror /
composite-action consolidation).

Verified: full live run against a throwaway repo root fetches 41,888,096
bytes and installs a 79,826,272-byte binary, byte-size-identical to the
existing local sidecar. `bash -n` clean.
Retrying was not enough. Within one hour on 2026-08-01,
johnvansickle.com escalated from throttling to refusing this runner
outright:

  1. First: a rate-limit notice served with HTTP 200 (caught by the size
     gate added in the previous commit).
  2. Then: connection timeouts —
     `curl: (28) Failed to connect to johnvansickle.com port 443 after
     30002 ms` — 0 bytes, five attempts, all five dead.

No retry policy fixes a host that has stopped accepting the connection.
The defect is depending on one small third-party host for a file CI
fetches 4x per run (3-platform matrix + bindings-drift).

Adds BtbN/FFmpeg-Builds on GitHub Releases as a fallback mirror. It is
served from GitHub's own CDN — the one host an Actions runner can always
reach — and it is a GPL static build exactly like johnvansickle's, so
nothing about the licensing of the bundled binary changes. johnvansickle
stays primary because it is 41 MB against BtbN's 127 MB.

Two details the mirror forced:

- `find -maxdepth 4` (was 2). The layouts differ: johnvansickle unpacks
  to `<dir>/ffmpeg`, BtbN to `<dir>/bin/ffmpeg`. A depth-2 search finds
  nothing on BtbN and fails with the unhelpful "could not find ffmpeg
  binary in extracted tarball".

- `if (( fetched )); then break; fi` rather than `(( fetched )) && break`.
  Under `set -e`, an AND-list whose first command fails takes the script
  down — with `fetched=0` the terse form would abort before ever trying
  the second mirror, which is precisely the case this commit exists to
  handle.

Verified end-to-end with the primary URL forced to a bogus host: 3
attempts exhausted, fell through to BtbN, downloaded 127,602,232 bytes,
extracted the nested binary, installed it, and
`ffmpeg -version` runs (N-125875, --enable-gpl --enable-version3).

Cross-ref #183 (sha256 pin + composite-action consolidation).
@utof
utof merged commit ebd83a7 into main Jul 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant